Skip to content

feat(connector): Finix Apple Pay Tokenize + SetupRecurring, Apple Pay & Google Pay webhooks - #2034

Open
shuklatushar226 wants to merge 6 commits into
mainfrom
feat/finix_grace
Open

feat(connector): Finix Apple Pay Tokenize + SetupRecurring, Apple Pay & Google Pay webhooks#2034
shuklatushar226 wants to merge 6 commits into
mainfrom
feat/finix_grace

Conversation

@shuklatushar226

Copy link
Copy Markdown
Contributor

Summary

Implements four Finix wallet flows in UCS, with a full logic-parity pass against the reference implementation in hyperswitch/crates/hyperswitch_connectors/src/connectors/finix:

  • Apple Pay Tokenize (PaymentMethodToken)
  • Apple Pay SetupRecurring (SetupMandate)
  • Apple Pay Webhooks
  • Google Pay Webhooks

Flow-name mapping for reviewers: Tokenize / SetupRecurring are the UCS gRPC rpc names; the legacy Hyperswitch flow types are PaymentMethodToken / SetupMandate. Parity was assessed against the latter.

The work was implemented flow-by-flow, then reviewed by an independent adversarial audit that validated claims against the real Finix OpenAPI (https://docs.finix.com/_spec/api/index.yaml) rather than only against the reference implementation. The audit found two regressions introduced by the first four commits; both are fixed in 5fdd260dd, which also adds a status-matrix test to pin the behaviour.

Commits

Commit Scope
44b89f158 Apple Pay Tokenize — token structs, build_apple_pay_instrument_request(), Tokenize + SetupMandate arms
4f59b3895 SetupRecurring parity — 4-digit card expiry, Google Pay name/address, real disabled-instrument errors
f809ad890 Incoming-webhook parity — unify status mapping, fix dispute/tags decoding, stop mis-classifying ledger transfers
6c8aae17c Dispute webhook decodes a null amount; Google Pay webhook verification
5fdd260dd Audit remediation — restore CANCELED failure semantics, reconcile webhook event type with payload status
158f877c2 Record verified Finix documentation links

Defects fixed along the way

These were found while implementing the four flows and are fixed here:

  1. Ledger transfers were resolved as payments. A SETTLEMENT transfer returned PAYMENT_INTENT_SUCCESS with a payment reference — a merchant payout would have been applied to a payment attempt as a charge. Now WebhookEventTypeNotFound.
  2. Every dispute webhook was undecodable. currency was required but Finix omits it; because FinixEmbedded is #[serde(untagged)], the failure took down get_event_type and get_webhook_event_reference too.
  3. FinixDisputes.amount was required but Finix declares it nullable: true — same untagged-decode collapse.
  4. FinixPaymentStatus had no #[serde(other)], so a RETURNED state broke PSync/Refund deserialization outright.
  5. Card expiration_year was sent verbatim — a merchant sending "30" stored a card-on-file mandate with expiration_year: 30. Now expanded via the existing shared util. Verified live: sent "30", read back 2030.
  6. Google Pay dropped billing name/address while the Apple Pay arm sent them.
  7. disabled_instrument_error collapsed two distinct failures into NO_ERROR_CODE/NO_ERROR_MESSAGE.
  8. Webhook HMAC used String::from_utf8_lossy — U+FFFD substitution would turn a valid signature into a mismatch. Now byte-exact, which Finix's docs require.

Structural cleanup

Deleted the webhook-only FinixState enum so FinixPaymentStatus is the single deserialization source for Finix state, and routed all 8 flows (Authorize, PSync, Capture, RSync, Void, Refund, RepeatPayment, webhooks) through one canonical get_finix_attempt_status(), deleting 8 hand-rolled inline matches. The webhook event-type mapper is now defined in terms of the status mapper, so the two cannot drift apart by hand.

Testing

  • Webhooks: 32/32 live assertions via gRPC EventService/ParseEvent + /HandleEvent with real HMAC-SHA256 Finix-Signature headers. The same suite scores 18-passed / 14-failed against the pre-remediation binary; the 14 are exactly the regression rows.
  • Card end-to-end against Finix sandbox: Create → Tokenize → Authorize(MANUAL) → PSync → Capture → PSync, plus Authorize → Void → PSync, plus a decline path (4000000000009987 → 402 → AUTHORIZATION_FAILED). Byte-identical before and after remediation.
  • Recurring, live via Card: SetupRecurring → 201 CHARGED with mandateReference, then an MIT RecurringPaymentService/Charge against that mandate → 201.
  • New finix/test.rs pins a 54-triple (flow, state, is_void) → AttemptStatus matrix, plus canceled_without_void_is_a_payment_failure, unknown_is_never_terminal, pending_void_stays_pending.
  • cargo build, cargo clippy --all-targets, cargo +nightly fmt --check, and 7 unit tests all clean.

Known testing limitation

No wallet-token-bearing call can return 2xx in this environment. Finix cryptographically verifies the Apple-issued PassKit signature server-side and publishes no sandbox wallet token; a structurally-correct request returns 422 INVALID_FIELD "Invalid Apple Pay token" while the identical Card request succeeds. Apple Pay / Google Pay request construction is therefore verified structurally, not live — request JSON shape, error surfacing with real code/message/reason, and fast-failing negative paths. Webhook flows are unaffected and fully live-tested, since they are payload-driven.

Both wallet token shapes were verified against the vendor OpenAPI examples: Apple Pay is the wrapped {"token":{...}} object serialized to a string, Google Pay is the raw ECv2 token string with no wrapper.

Intentional deviations from the reference

Every deviation below is deliberate and was reviewed by the independent audit.

# Deviation Rationale
1 UNKNOWNPending (reference: terminal failure) An indeterminate state must never be terminal. The reference is already self-inconsistent here — it does exactly this for DEBIT transfers.
2 PENDING void stays Pending (reference: Voided) The reference reports funds released before Finix says so.
3 CANCELEDVoided only when is_void is set Restored in 5fdd260dd. On non-void paths there is no void in flight, and collapsing both contexts discards the decline reason.
4 RETURNED modelled explicitly (reference has it commented out) Finix documents it in transfer.state; leaving it unmodelled broke PSync deserialization.
5 HMAC over raw body bytes, not from_utf8_lossy Finix docs require byte-exact. The reference has a latent bug here.
6 Apple Pay header fields are Secret<String> (reference: plain String) Cryptogram material. Wire bytes identical.
7 DEBIT/REVERSAL guards in the webhook content builders No reference equivalent, but load-bearing in UCS: the shared dispatcher falls back to the payment builder for unrecognised event types, so without the guard a FEE or SETTLEMENT would post to a payment attempt.
8 Void hardcodes is_void = Some(true) The reference would report SUCCEEDED → Charged on a void.
9 SetupMandate sends tags{merchant_reference} (reference: tags: None) POST /payment_instruments has no idempotency_id; the tag is the only correlation handle.
10 SetupMandate rejects a disabled instrument / missing id (reference: unconditional Charged) Persisting a disabled instrument as a mandate strands every later MIT.
11 RepeatPayment rejects NetworkMandateId / NetworkTokenWithNTI Confirmed against the OpenAPI: Finix exposes no network_transaction_id property at all; it chains by its own transfer id.
12 EventNotSupportedIncomingWebhookEventUnspecified Architecture-forced; the Hyperswitch UCS client maps it back.
13 connector_dispute_id stays None on the dispute reference Lets the normaliser resolve dispute.transfer, matching the reference's PaymentId(ConnectorTransactionId(..)).
14 No Google-Pay-specific webhook code Verified, not assumed — Finix's event catalogue has no wallet entity, its own Google Pay guide publishes a transfer field-identical to a card transfer, and the OpenAPI transfer schema has 47 properties with no wallet discriminator.
15 No webhook timestamp-freshness check Absent in both implementations; adding it unilaterally breaks parity and risks rejecting Finix's own 15-minute retries. Recommended as a framework-level follow-up.

Known issues NOT fixed here

Deliberately out of scope. Each is pre-existing and/or needs a change outside connectors/finix*.

  • credential_on_file is never sent on RepeatPayment (CRITICAL). The code asserts "No MIT-specific fields are required"; the Finix OpenAPI contradicts this — POST /transfers and /authorizations accept credential_on_file{type, initial_transfer_id}, with initial_transfer_id required for both RECURRING and UNSCHEDULED. Month-2 subscription charges therefore post as ordinary CNP transactions, risking soft declines, loss of recurring interchange tier, and Visa Stored Credential Framework / Mastercard MIT assessments. hyperswitch Direct has the identical gap, so fixing it here would deliberately break parity — it needs a coordinated change in both, and should be raised against Direct too. The plumbing already exists unused: RepeatPaymentData.mit_category maps 1:1 onto Finix's enum and is already consumed by shift4/checkout/dlocal/tsys_transit.
  • Dispute webhooks cannot report an amount (HIGH). process_dispute_webhook needs a currency that does not exist in Finix's 19-property dispute schema. It is not fixable in connector scope: domain EventContext carries only capture_method, the proto DisputeEventContext is an empty message, and dispute.transfer would need an outbound GET. Related finding: ForeignTryFrom<DisputeWebhookDetailsResponse> for DisputeResponse hardcodes dispute_amount: None, so every connector currently computes a dispute amount/currency that no caller consumes. Rather than fabricate a value, the error now names the real gap (WebhookMissingRequiredContext) instead of falsely claiming Finix omitted a field it does not define.
  • Endpoint-verification probes fail in HandleEvent. webhook_utils.rs:72-81 routes any non-payment/refund/dispute event to the payment builder, which cannot parse {}. ParseEvent handles the probe correctly. Affects every connector.
  • No webhook replay window / non-constant-time signature compare. Finix recommends a 300s window. Framework-level, reference-parity.
  • Webhook signing-key rotation unsupported. Finix's updateWebhook exposes generate_new_secret / previous_secret_active_hours, so during rotation either key may be valid; both implementations use only secret and ignore additional_secret.

Router-data shadow validation

Not run. juspay/ucs-shadow-validation-service requires a live legacy Hyperswitch router and a live UCS grpc-server both hitting real Finix sandbox — it has no fixture or replay mode — and the Docker path is unavailable in the build environment. Separately, webhook parity is not measurable with that service as it stands: WebhookShadowSnapshot carries no payment_id, and routerDataHandler rejects any payload missing it (VS_42) before comparing. A complete runbook for the cheapest viable path, including the exact [comparison_service] config and wallet rollout keys, is available and can be attached on request.

🤖 Generated with Claude Code

Tushar Shukla and others added 6 commits August 3, 2026 03:37
…etupMandate flows

Finix exchanges wallet device tokens for a Payment Instrument via
POST /payment_instruments before the wallet can be charged. Extend the
existing Google Pay tokenization path to also cover Apple Pay:

- `should_do_payment_method_token` now returns true for
  `PaymentMethodType::ApplePay`, so the Tokenize flow is invoked.
- Add `FinixApplePay*` types modelling the PassKit payment token
  (paymentData, paymentMethod, transactionIdentifier); all cryptogram
  material is held in `Secret` from construction.
- Add `build_apple_pay_instrument_request`, which base64-decodes the
  PassKit token and re-serializes it as the JSON string Finix expects in
  `third_party_token`, with `merchant_identity` taken from the connector
  auth. Shared by Tokenize and SetupMandate so the two paths cannot drift.

Validation: cargo build clean; card Tokenize regression passes. The
Apple Pay grpcurl call returns Finix 422 "Invalid Apple Pay token"
because Finix cryptographically verifies the Apple-issued PassKit
signature server-side and no Finix sandbox Apple Pay token exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…oogle Pay name/address, real disabled-instrument errors

This extends commit 44b89f1 (ApplePay Tokenize + SetupMandate arm), it does
not replace it.

Fix 1: card `expiration_year` was sent verbatim, so a 2-digit year like "30"
was stored as year 30; now routed through the shared 4-digit expansion util.
New helper `get_finix_expiration_year()`; call sites in Tokenize and
SetupMandate.

Fix 2: Google Pay instrument request dropped billing `name` and `address`
(Apple Pay arm already sent them); factored into
`build_google_pay_instrument_request` mirroring the Apple Pay helper, and its
parse error now names Google Pay's own token fields.

Fix 3: `disabled_instrument_error` (now `instrument_not_usable_error`)
collapsed `enabled: false` and a missing `id` into
NO_ERROR_CODE/NO_ERROR_MESSAGE; each branch now returns a real connector code,
message and reason. Applied to both Tokenize and SetupMandate response
transformers.

Validation: cargo build clean, clippy clean, cargo fmt clean. Live-verified
against Finix sandbox: SetupRecurring(Card) -> PIi1XiiNbq2A3xF3UYrXfJrT /
CHARGED with mandateReference populated, expiry "30" stored back as 2030;
RecurringPaymentService/Charge MIT against that mandate ->
TRpxD1Z7dFpJtKkKBFbJ2GBo / CHARGED; Tokenize(Card) regression ->
PIdrr9t8dBcM3Kn5vRFA9WUH, expiry "31" stored as 2031. Apple Pay and Google Pay
SetupRecurring are structurally verified only: Finix returns 422 INVALID_FIELD
"Invalid Apple Pay token" / "Invalid Google Pay token" because it verifies the
wallet-issued signature server-side and no sandbox wallet token exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… fix dispute/tags decoding, stop mis-classifying ledger transfers

Brings the Finix `IncomingWebhook` implementation to parity with the Direct
gateway and fixes four defects that made real Finix deliveries either fail to
decode or be reported as the wrong resource.

Status mapping is now canonical
* `FinixState` (webhook-only) is deleted; `FinixPaymentStatus` is the single
  deserialization source of truth for the Finix `state` field. It gains
  `#[serde(other)]` (an unmapped state no longer fails the whole body) and an
  explicit `RETURNED` variant, which is documented on `GET /transfers/{id}`.
* One `get_finix_attempt_status(state, flow, is_void)` and one
  `From<&FinixPaymentStatus> for RefundStatus` now serve Authorize, PSync,
  Capture, Void, Refund, RSync, RepeatPayment and the webhook path, so a
  webhook can no longer report a different status than a sync for the same
  connector state. `UNKNOWN` stays `Pending` everywhere instead of being a
  terminal failure on the webhook path only.

Body decoding
* `FinixDisputes.currency` is now `Option<Currency>`: Finix's Dispute resource
  has no `currency` key, so the previous required field failed decoding for
  every real dispute event and took `get_event_type` and
  `get_webhook_event_reference` down with it. The absence is reported as
  `WebhookMissingRequiredField { field: "currency" }` from the dispute builder
  only, mirroring the Direct gateway.
* `tags` is now `Option<FinixTags>` (as in Direct); an authorization event that
  omits it previously failed to decode.
* `FinixDisputeState` gains `#[serde(other)]`; `FinixEmbedded` gains the
  `Evidences` variant so the default-on `evidence.created` subscription
  degrades to "event not supported" rather than a decode failure.

Event classification and references
* Transfer handling is matched per `type` again instead of collapsing to
  "REVERSAL vs everything else". CREDIT / FEE / ADJUSTMENT / DISPUTE / RESERVE
  / SETTLEMENT and an absent `type` are platform-ledger movements: they now
  yield no event and `WebhookEventTypeNotFound` instead of being reported as a
  successful payment against the transfer id.
* The payment and refund builders reject any resource that is not an
  authorization / DEBIT transfer / REVERSAL transfer, so the shared
  dispatcher's fallback to the payment builder cannot mis-report them.

Source verification
* The signed message appends the raw body byte-for-byte instead of going
  through `String::from_utf8_lossy`, which would substitute U+FFFD before
  hashing and turn a valid signature into a mismatch.

Verified live against `EventService/ParseEvent` and `EventService/HandleEvent`
with Finix-shaped payloads: payment success/failure, refund success/failure,
dispute with and without `currency`, void, RETURNED, unknown state, settlement
transfer, evidence entity, tags omitted, endpoint-verification probe, and
signature failures (wrong key, missing header, missing sig, non-hex sig, no
secret).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ay webhook verification

Google Pay scope: Finix emits no wallet-specific webhook. Its webhook event
catalogue has no Google Pay entity or event type, and the `transfer`,
`authorization` and `dispute` resources carry no instrument-type
discriminator — Finix's own Google Pay guide shows a Google-Pay-originated
`POST /transfers` response that is field-for-field identical to a card
transfer, its only wallet trace being `source` pointing at a `PI…` id. The
sole place `GOOGLE_PAY` appears on the webhook surface is `instrument.type`
on the `instrument` entity, which this connector deliberately does not
model. Google Pay therefore needs no wallet-specific webhook code, and none
is added here.

Verifying that against real payloads did surface one decoding defect:

* `FinixDisputes.amount` was a required `MinorUnit`, but Finix declares
  `Dispute.amount` as `nullable: true` (OpenAPI
  `components.schemas.dispute.properties.amount`) and marks no Dispute field
  as required. A dispute delivered with `"amount": null` failed
  `FinixWebhookBody` deserialization outright and — because `FinixEmbedded`
  is untagged — took `get_event_type` and `get_webhook_event_reference` down
  with it, so the event surfaced as an undiagnosable
  `WebhookBodyDecodingFailed` with nothing pointing at the cause. It is now
  `Option<MinorUnit>`; the absence is reported as
  `WebhookMissingRequiredField { field: "amount" }` from
  `build_finix_dispute_webhook_response` only, exactly as the missing
  `currency` key already is, and no zero amount is fabricated.

Verified live against `EventService/ParseEvent` and `EventService/HandleEvent`
over gRPC with real HMAC-SHA256 `Finix-Signature` headers, using 21
Google-Pay-originated payloads built from Finix's documented webhook samples:
sale SUCCEEDED/PENDING/FAILED, authorization success and void, refund
success/failure, dispute with and without `currency`, dispute with a null
amount, RETURNED, unknown state, settlement transfer, `instrument.created`
for a GOOGLE_PAY instrument, optional fields omitted, the documented
"Authorization Captured" sample, wrong signing key, and Finix's pretty-printed
wire format signed over the exact bytes versus over re-serialized JSON.

Before this change the null-amount dispute returned "Failed to decode webhook
event body" from both entry points; it now parses to `WEBHOOK_DISPUTE_OPENED`
with the correct transfer reference and reports "Missing required field
'amount'" from HandleEvent alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… webhook event type with payload status

Remediation of the independent parity audit of 44b89f1..6c8aae1.

F-2 (regression): the `get_finix_attempt_status()` unification applied
`CANCELED -> Voided` unconditionally, so a 201 `{"state":"CANCELED",
"failure_code":"card_expired"}` on Authorize/Capture/PSync/RepeatPayment stopped
being a payment failure. `is_payment_failure(Voided)` is false, so the
transformer returned `Ok(TransactionResponse)` instead of
`Err(ErrorResponse{code:"card_expired"})` and dropped the decline reason. Move
`CANCELED -> Voided` inside the `is_void == Some(true)` branch; outside a void it
joins FAILED/RETURNED. `UNKNOWN -> Pending` and the PENDING-void -> Pending
correction are kept, and Void/Refund/RSync stay byte-identical to 45351c2.

F-1 (regression): `get_finix_webhook_event_type` was left on the old grouping
while the content status mapping was unified, so a successful void
(is_void:true, CANCELED) emitted `PaymentIntentCancelFailure` carrying a `Voided`
payload. The payment arms now derive their event type from
`get_finix_attempt_status` via `get_finix_webhook_payment_event_type`, which makes
agreement structural. DEBIT transfers now honour `is_void` for the same reason.
`UNKNOWN` keeps emitting no event (a non-assertion, so it cannot contradict).

F-3: `FinixWebhookPaymentsResponse.amount`/`.currency` are required but never
read, and `FinixEmbedded` is `#[serde(untagged)]`, so one missing key collapsed the
whole body decode and took get_event_type, get_webhook_event_reference and
get_webhook_resource_object down with it. Now `Option<T>` + `#[serde(default)]`,
matching what was already done for `tags`. Same treatment for the equally unread
`FinixAuthorizeResponse.amount`/`.currency`.

F-9: `get_finix_expiration_year("203")` returned `Ok(203)` and sent
`expiration_year: 203`; `get_expiry_year_4_digit()` only expands two-digit input.
Non-four-digit years are now rejected with the existing typed `InvalidDataFormat`.

F-8: delete the dead `impl From<&FinixPaymentStatus> for AttemptStatus`, which
hardcoded `FinixFlow::Transfer` and would have silently produced `Charged` in an
authorization context.

F-4 (NOT fixed — needs a core change): `process_dispute_webhook` hard-errors on
`dispute.currency`, which the Finix `dispute` schema does not define, so a merchant
is never notified of a chargeback. It cannot be fixed in connector scope:
`process_dispute_webhook` takes no `EventContext`, `EventContext` carries only
`capture_method`, and the proto `DisputeEventContext` is empty. Fabricating a
currency is not acceptable, so the failure is documented in place and the error
switched to `WebhookMissingRequiredContext`, which names the actual gap.

Adds the first finix tests: a table-driven (flow, state, is_void) -> AttemptStatus
matrix, an event-type/status agreement property test, and expiry-year boundaries.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds 36 verified Finix documentation URLs (webhooks, wallets, payment
instruments, recurring) discovered during the ApplePay/GooglePay flow
work. Additive only; no existing entries modified.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@shuklatushar226
shuklatushar226 requested review from a team as code owners August 3, 2026 00:57
@XyneSpaces

Copy link
Copy Markdown

[should-fix] Dispute webhook handling now requires dispute.currency and dispute.amount, but the added Finix dispute model treats both as optional and the code comments note the resource may not carry currency. A real Finix dispute webhook missing either field will fail before producing DisputeWebhookDetailsResponse; please resolve these from event context or return an explicit unsupported event instead of wiring a path that always errors for live payloads.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants